<?php
/**
 * 微信支付V2
 * Author: Jike
 * Date: 2020/10/12
 * Time: 14:29
 */
namespace app\common;


use think\facade\Db;
use think\facade\Env;



class WxPayV2
{

    /*
     * 支付相关配置
     * ***必填
     * */
    private static array $config = [
        'app_id' => '',             //*** 微信应用AppId
        'merchant_id' => '',        //*** 商户号
        'app_secret' => '',         // 微信应用AppSecret
        'key' => '',                //*** 支付key
        'notify_url' => '',         // 支付异步通知地址
        'sign_type' => 'MD5',       //*** 签名方式,支持md5和sha256方式
        'wap_url' => '',            // 业务域名，h5支付必填
        'proxy_host' => '0.0.0.0',  // 代理IP
        'proxy_port' => 0,          // 代理端口
        'api_cert_path' => '',      // 证书路径,退款和企业付款必填 *** 填写绝对路径 ***
        'api_key_path' => '',       // 证书key路径
        'report_levenl' => 1,       // 上报等级，0.关闭上报; 1.仅错误出错上报; 2.全量上报
    ];


    /*
     * 系统返回值
     * */
    private static array $result = [
        'code' => 0,//0成功，其他失败
        'msg' => 'success',
        'data' => []//返回的数据，array or string
    ];


    /*
     * 初始化参数
     *
     * */
    private static function init(int $id = 1):void{

        self::$result = [
            'code' => 0,//0成功，其他失败
            'msg' => 'success',
            'data' => []
        ];

        $wxConfig = Db::name('wx_config')->find($id);

        self::$config['app_id'] = $wxConfig['app_id'] ?? '';//APP ID
        self::$config['app_secret'] = $wxConfig['app_secret'] ?? '';//APP Secret
        self::$config['merchant_id'] = $wxConfig['merchant_id'] ?? '';// 商户id
        self::$config['key'] = $wxConfig['pay_key_v2'] ?? '';//V2支付 key
        self::$config['pay_key_v3'] = $wxConfig['pay_key_v3'] ?? '';//V3支付key
        self::$config['serial_no'] = $wxConfig['serial_no'] ?? '';//微信平台证书序列号
        self::$config['certificate_serial'] = $wxConfig['certificate_serial'] ?? '';//API证书序列号
        $notify_url = $wxConfig['notify_url'] ?? '';
        $notify_url = $notify_url ? $notify_url : 'api/notify/wx';
        self::$config['notify_url'] = url($notify_url,[],true,true);//异步通知地址
        self::$config['sign_type'] = $wxConfig['sign_type'] ?? '';//签名类型
        self::$config['proxy_host'] = $wxConfig['proxy_host'] ?? '';//代理
        self::$config['proxy_port'] = $wxConfig['proxy_port'] ?? '';//代理端口
        $cert_path = Env::get('root_path') . 'cert/';//
        self::$config['api_cert_path'] =  $wxConfig['api_cert_path'] ? $cert_path . $wxConfig['api_cert_path'] : '';//api证书
        self::$config['api_key_path'] = $wxConfig['api_key_path'] ? $cert_path . $wxConfig['api_key_path'] : '';//api证书 key
        self::$config['cert_path'] = $wxConfig['cert_path'] ? $cert_path . $wxConfig['cert_path'] : '';//微信平台证书

    }


    /**
     * 退款
     * transaction_id、out_trade_no二选一，如果同时存在优先级：transaction_id> out_trade_no
     *
     * @param   string      $transaction_id 微信订单号
     * @param   string      $out_trade_no 商户系统内部的订单号
     * @param   int       $total_fee 订单金额，单位分
     * @param   int       $refund_fee 退款金额，单位分
     * @param   string      $out_refund_no  退款单号
     * @param   int         $payId        支付配置id
     * @return  array
     */
    public static function refund(int $total_fee,int $refund_fee,string $out_refund_no,string $transaction_id = '',string $out_trade_no = '',int $payId = 1):array
    {
        try {
            $url = "https://api.mch.weixin.qq.com/secapi/pay/refund";
            //检测必填参数
            if(!$transaction_id && !$out_trade_no) {
                throw new \Exception("退款申请接口中，out_trade_no、transaction_id至少填一个！");
            }else if(!$total_fee){
                throw new \Exception("退款申请接口中，缺少必填参数total_fee！");
            }else if(!$refund_fee){
                throw new \Exception("退款申请接口中，缺少必填参数refund_fee！");
            }
            self::init($payId);
            $config = self::$config;
            $param = [
                'appid' => $config['app_id'],
                'mch_id' => $config['merchant_id'],
                'op_user_id' => $config['merchant_id'],
                'nonce_str' => self::getNonceStr(),
                'transaction_id' => $transaction_id,
                'out_trade_no' => $out_trade_no,
                'out_refund_no' => $out_refund_no,
                'total_fee' => $total_fee,
                'refund_fee' => $refund_fee,
                'sign_type' => $config['sign_type']
            ];

            $param['sign'] = self::MakeSign($param);//签名
            $xml = self::ToXml($param);

            $response = self::postXmlCurl($xml, $url, true, 6);
            $order = self::FromXml($response);

            if ($order['return_code'] != "SUCCESS") {
                throw new \Exception($order['return_msg']);
            }
            if($order['result_code'] != "SUCCESS"){
                throw new \Exception($order['err_code_des']);
            }
            self::$result['data'] = $order['refund_id'] ?? '';
        }catch (\Exception $we){
            self::$result['code'] = 41009;
            self::$result['msg'] = $we->getMessage();
        }
        return self::$result;
    }


    /**
     * 微信支付
     *
     * @param   string      $out_trade_no    商户订单号
     * @param   int         $total_fee        支付金额，单位（分）
     * @param   string      $trade_type   交易类型:JSAPI -JSAPI支付;NATIVE -扫码支付;APP -APP支付,MWEB -H5支付
     * @param   string      $openId       微信应用ID
     * @param   string      $body         商品描述
     * @param   int         $payId        支付配置id
     * @return  array
     * */
    public static function pay(string $out_trade_no,int $total_fee,string $trade_type,string $body,string $openId='',int $payId = 1):array
    {
        $types = ['JSAPI','NATIVE','APP','MWEB'];
        $trade_type = strtoupper($trade_type);
        //$total_fee = 1;
        try{
            //检测必填参数
            if(!in_array($trade_type,$types)){
                throw new \Exception('trade_type 只能是JSAPI、NATIVE、APP、MWEB其中一种');
            }
            if(!$out_trade_no){
                throw new \Exception('商户订单号必须填写');
            }
            if(!$total_fee){
                throw new \Exception('支付金额必须填写');
            }
            //关联参数
            if($trade_type == "JSAPI" && !$openId){
                throw new \Exception("统一支付接口中，缺少必填参数openid！trade_type为JSAPI时，openid为必填参数！");
            }
            self::init($payId);
            $config = self::$config;
            $param = [
                'appid' => $config['app_id'],//微信应用ID
                'mch_id' => $config['merchant_id'],//商户号
                'nonce_str' => self::getNonceStr(),//随机字符串
                'out_trade_no' => $out_trade_no,//商户订单号
                'body' => $body,//商品描述
                'total_fee' => $total_fee,//支付金额
                'time_start' => getDateTime("YmdHis"),
                //'time_expire' => date("YmdHis", strtotime('+2 hour')),
                'notify_url' => $config['notify_url'],//回调地址
                'trade_type' => $trade_type,//支付类型
                'spbill_create_ip' => $_SERVER['REMOTE_ADDR'],//终端ip
                'sign_type' => $config['sign_type']
            ];

            if($trade_type == "MWEB"){
                $param['scene_info'] = '{"h5_info":{"type": "wap","wap_url": "'.$config['wap_url'].'","wap_name": "'.$body.'"}}';
            }
            if($openId){
                $param['openid'] = $openId;
            }
            if($trade_type == "NATIVE"){
                $param['product_id'] = getDateTime('U');
            }

            //②、统一下单WxPayUnifiedOrder


            $url = "https://api.mch.weixin.qq.com/pay/unifiedorder";

            //签名
            $param['sign'] = self::MakeSign($config,$param);

            $xml = self::ToXml($param);


            $startTimeStamp = intval(getDateTime('U'));//请求开始时间
            $response = self::postXmlCurl($xml, $url, false, 6);
            $order = self::FromXml($response);
            self::reportCostTime($url, $startTimeStamp, $order);//上报请求花费时间

            if($order['return_code'] != "SUCCESS"){
                throw new \Exception($order['return_msg'] ?? '');
            }elseif($order['result_code'] != "SUCCESS"){
                throw new \Exception($order['err_code_des'] ?? '');
            }
            if($trade_type == "JSAPI" || $trade_type == "APP"){
                $data = self::GetJsApiParameters($config,$order,$trade_type == "APP");
            }else{
                $data = $order;
            }
            self::$result['data'] = $data;
        }catch(\Exception $e) {
            self::$result['code'] = 1024;
            self::$result['msg'] = $e->getMessage();
        }
        return self::$result;
    }

    /*
     * 查询订单
     * @param WxPayConfig $config
     * @param string $transaction_id    微信订单号
     * @param string $out_trade_no    商户订单号
     * @return bool
     * */
    public static function queryOrder(string $transaction_id = '',string $out_trade_no = ''):bool
    {

        $url = "https://api.mch.weixin.qq.com/pay/orderquery";
        $res = false;
        try{
            //检测必填参数
            if(!$transaction_id && !$out_trade_no) {
                throw new \Exception("订单查询接口中，out_trade_no、transaction_id至少填一个！");
            }
            self::init();
            $config = self::$config;
            $param = [
                'appid' => $config['app_id'],//微信应用ID
                'mch_id' => $config['merchant_id'],//商户号
                'nonce_str' => self::getNonceStr(),//随机字符串
                'out_trade_no' => $out_trade_no,//商户订单号
                'transaction_id' => $transaction_id,//微信订单号
            ];

            //签名
            $param['sign'] = self::MakeSign($param);
            $xml = self::ToXml($param);

            $startTimeStamp = intval(getDateTime('U'));//请求开始时间
            $response = self::postXmlCurl($xml, $url, false, 6);
            $result = self::FromXml($response);
            self::reportCostTime($url, $startTimeStamp, $result);//上报请求花费时间

            if(array_key_exists("return_code", $result)
                && array_key_exists("result_code", $result)
                && $result["return_code"] == "SUCCESS"
                && $result["result_code"] == "SUCCESS")
            {
                $res = true;
            }

        }catch (\Exception $e){
            $res = false;
        }
        return $res;
    }


    public static function transfers(string $partner_trade_no,int $fee,string $openid,string $name='',string $desc='红包提现'):array
    {
        $url = 'https://api.mch.weixin.qq.com/mmpaymkttransfers/promotion/transfers';

        try{

            if(!$partner_trade_no){
                throw new \Exception('商户订单号必须填写！');
            }
            if(!$fee){
                throw new \Exception('转账金额必须填写！');
            }
            if(!$openid){
                throw new \Exception('转账用户openID必须填写！');
            }
            $check_name = $name ? 'FORCE_CHECK' : 'NO_CHECK';
            self::init();
            $config = self::$config;
            $param = [
                'mch_appid' => $config['app_id'],
                'mchid' => $config['merchant_id'],
                'nonce_str' => self::getNonceStr(),
                'partner_trade_no' => $partner_trade_no,
                'openid' => $openid,
                'check_name' => $check_name,
                're_user_name' => $name,
                'amount' => intval($fee),
                'desc' => $desc
            ];
            $param['sign'] = self::MakeSign($param);
            $xml = self::ToXml($param);
            $result = self::postXmlCurl($xml,$url,true);
            $result =  self::FromXml($result);
            if($result['return_code'] === "SUCCESS" && $result['result_code'] === "SUCCESS"){
                self::$result['data'] = [
                    'mch_appid' => $result['mch_appid'] ?? '',
                    'mchid' => $result['mchid'] ?? '',
                    //'device_info' => $result['device_info'],
                    'nonce_str' => $result['nonce_str'] ?? '',
                    'partner_trade_no' => $result['partner_trade_no'] ?? '',
                    'payment_no' => $result['payment_no'] ?? ''
                ];
            }else{
                throw new \Exception($result['err_code_des']);
            }
        }catch (\Exception $e){
            self::$result['code'] = 1231;
            self::$result['msg'] = $e->getMessage();
        }
        return self::$result;
    }


    /**
     * post提交xml格式数据
     * @param   string       $xml       xml数据
     * @param   string       $url       提交地址
     * @param   bool         $useCert   是否使用证书
     * @param   int          $timeOut    超时时间
     * @return  mixed
     * @throws
     */
    protected static function postXmlCurl(string $xml,string $url,bool $useCert = false,int $timeOut = 30):string
    {
        $ch = curl_init();
        $curlVersion = curl_version();
        $config = self::$config;
        $ua = "WXPaySDK/3.0.9 (".PHP_OS.") PHP/".PHP_VERSION." CURL/".$curlVersion['version']." ".$config['merchant_id'];

        //设置超时
        curl_setopt($ch, CURLOPT_TIMEOUT, $timeOut);

        //如果有配置代理这里就设置代理
        $proxyHost = $config['proxy_host'] ?? '';
        $proxyPort = $config['proxy_port'] ?? '';

        if($proxyHost != "0.0.0.0" && $proxyPort != 0){
            curl_setopt($ch,CURLOPT_PROXY, $proxyHost);
            curl_setopt($ch,CURLOPT_PROXYPORT, $proxyPort);
        }
        curl_setopt($ch,CURLOPT_URL, $url);

        /*
         * 防止curl 60错误
         * */
        if(stripos($url,"https://")!==FALSE) {
            curl_setopt($ch, CURLOPT_SSLVERSION, CURL_SSLVERSION_TLSv1);
            curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE);
            curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE);
        }else{
            curl_setopt($ch,CURLOPT_SSL_VERIFYPEER,TRUE);
            curl_setopt($ch,CURLOPT_SSL_VERIFYHOST,2);//严格校验
        }

        //curl_setopt($ch,CURLOPT_SSL_VERIFYPEER,true);
        //curl_setopt($ch,CURLOPT_SSL_VERIFYHOST,2);//严格校验

        curl_setopt($ch,CURLOPT_USERAGENT, $ua);
        //设置header
        curl_setopt($ch, CURLOPT_HEADER, FALSE);
        //要求结果为字符串且输出到屏幕上
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);

        if($useCert){
            //设置证书
            //使用证书：cert 与 key 分别属于两个.pem文件
            //证书文件请放入服务器的非web目录下
            $sslCertPath = $config['api_cert_path'] ?? '';
            $sslKeyPath = $config['api_key_path'] ?? '';
            curl_setopt($ch,CURLOPT_SSLCERTTYPE,'PEM');
            curl_setopt($ch,CURLOPT_SSLCERT, $sslCertPath);
            curl_setopt($ch,CURLOPT_SSLKEYTYPE,'PEM');
            curl_setopt($ch,CURLOPT_SSLKEY, $sslKeyPath);
        }
        //post提交方式
        curl_setopt($ch, CURLOPT_POST, TRUE);
        curl_setopt($ch, CURLOPT_POSTFIELDS, $xml);
        //运行curl
        $data = curl_exec($ch);
        //返回结果
        if($data){
            curl_close($ch);
            return $data;
        } else {
            $error = curl_errno($ch);
            curl_close($ch);
            throw new \Exception("curl出错，错误码:$error");
        }
    }

    /**
     *
     * 上报数据， 上报的时候将屏蔽所有异常流程
     * @param string $url
     * @param int $startTimeStamp
     * @param array $data
     * @throws \Exception
     */
    private static function reportCostTime(string $url,int $startTimeStamp,array $data):void
    {
        $config = self::$config;
        //如果不需要上报数据
        $reportLevenl = $config['report_levenl'];
        if($reportLevenl == 0){
            return;
        }
        //如果仅失败上报
        if($reportLevenl == 1 &&
            array_key_exists("return_code", $data) &&
            $data["return_code"] == "SUCCESS" &&
            array_key_exists("result_code", $data) &&
            $data["result_code"] == "SUCCESS")
        {
            return;
        }
        $endTimeStamp = getDateTime('Uv');
        //上报逻辑
        $param = [
            'interface_url' => $url,
            'execute_time_' => $endTimeStamp - $startTimeStamp,
        ];


        //返回状态码
        if(array_key_exists("return_code", $data)){
            $param['return_code'] = $data["return_code"];
        }
        //返回信息
        if(array_key_exists("return_msg", $data)){
            $param['return_msg'] = $data["return_msg"];
        }
        //业务结果
        if(array_key_exists("result_code", $data)){
            $param['result_code'] = $data["result_code"];
        }
        //错误代码
        if(array_key_exists("err_code", $data)){
            $param['err_code'] = $data["err_code"];
        }
        //错误代码描述
        if(array_key_exists("err_code_des", $data)){
            $param['err_code_des'] = $data["err_code_des"];
        }
        //商户订单号
        if(array_key_exists("out_trade_no", $data)){
            $param['out_trade_no'] = $data["out_trade_no"];
        }
        //设备号
        if(array_key_exists("device_info", $data)){
            $param['device_info'] = $data["device_info"];
        }

        try{
            self::report($config, $param);
        } catch (\Exception $e){
            //不做任何处理
        }
    }

    /**
     *
     * 测速上报，该方法内部封装在report中，使用时请注意异常流程
     * WxPayReport中interface_url、return_code、result_code、user_ip、execute_time_必填
     * appid、mchid、spbill_create_ip、nonce_str不需要填入
     * @param array $config  配置对象
     * @param array $param
     * @param int $timeOut
     * @throws \Exception
     * @return string 成功时返回，其他抛异常
     */
    private static function report(array $config,array $param,int $timeOut = 1)
    {
        $url = "https://api.mch.weixin.qq.com/payitil/report";
        //检测必填参数
        if(!$param['interface_url']) {
            throw new \Exception("接口URL，缺少必填参数interface_url！");
        } if(!$param['return_code']) {
            throw new \Exception("返回状态码，缺少必填参数return_code！");
        } if(!$param['result_code']) {
            throw new \Exception("业务结果，缺少必填参数result_code！");
        } if(!$param['user_ip']) {
            throw new \Exception("访问接口IP，缺少必填参数user_ip！");
        } if(!$param['execute_time_']) {
            throw new \Exception("接口耗时，缺少必填参数execute_time_！");
        }
        $param['appid'] = $config['app_id'];//公众账号ID
        $param['mch_id'] = $config['merchant_id'];//商户号
        $param['user_ip'] = $_SERVER['REMOTE_ADDR'];//终端ip
        $param['time'] = date("YmdHis");//商户上报时间
        $param['nonce_str'] = self::getNonceStr();//随机字符串

        $param['sign'] = self::MakeSign($param);//签名
        $xml = self::ToXml($param);

        //$startTimeStamp = self::getMillisecond();//请求开始时间
        $response = self::postXmlCurl( $xml, $url, false, $timeOut);
        return $response;
    }

    /**
     *
     * 获取jsapi支付的参数
     *
     * @param array $config 微信支付配置类
     * @param array $result 统一支付接口返回的数据
     * @param bool $app 是否APP支付
     * @throws \Exception
     *
     * @return array
     */
    private static function GetJsApiParameters(array $config,array $result,bool $app = false):array
    {
        if(!isset($result['appid']) || !isset($result['prepay_id']) || !$result['prepay_id']){
            throw new \Exception("参数错误");
        }

        $param = [
            'appId' => $result["appid"],
            'timeStamp' => getDateTime('U'),
            'nonceStr' => self::getNonceStr(),
            'package' => $app ? "Sign=WXPay" : "prepay_id=" . $result['prepay_id'],
        ];

        if($app){
            $param['partnerId'] = $config['merchant_id'];
            $param['prepayId'] = $result['prepay_id'];
        }else{
            $param['signType'] = 'MD5';
        }

        $param['paySign'] = self::MakeSign($param);

        return $param;
    }

    /**
     *
     * 产生随机字符串，不长于32位
     * @param int $length
     * @return string
     */
    protected static function getNonceStr(int $length = 32):string
    {
        $chars = "abcdefghijklmnopqrstuvwxyz0123456789";
        $str ="";
        $max = strlen($chars) - 1;
        for ( $i = 0; $i < $length; $i++ )  {
            $str .= substr($chars, mt_rand(0, $max), 1);
        }
        return $str;
    }

    /**
     * 输出xml字符
     * @param   array $params
     * @throws  \Exception
     * @return  string
     **/
    public static function ToXml(array $params):string
    {
        if(!is_array($params) || count($params) <= 0)
        {
            throw new \Exception("数组数据异常！");
        }

        $xml = "<xml>";

        foreach ($params as $key=>$val)
        {
            if(is_numeric($val)){
                $xml .= "<".$key.">".$val."</".$key.">";
            }else{
                $xml .= "<".$key."><![CDATA[".$val."]]></".$key.">";
            }
        }

        $xml .= "</xml>";

        return $xml;
    }

    /**
     * 将xml转为array
     * @param  string $xml      xml标准格式字符串
     * @param  bool $returnArr 是否返回数组
     * @throws \Exception
     * @return array|object
     */
    public static function FromXml(string $xml,bool $returnArr = true)
    {
        if(!$xml){
            throw new \Exception("xml数据异常！！！");
        }
        //将XML转为SimpleXMLElement对象
        //禁止引用外部xml实体
        libxml_disable_entity_loader(true);
        $result = simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOCDATA);

        if($result === false){
            throw new \Exception("无效的xml数据！");
        }

        //返回数组
        if($returnArr){
            $result = json_decode(json_encode($result), true);
        }

        return $result;
    }

    /**
     * 格式化参数格式化成url参数
     * @param   array $values
     * @return  string
     */
    public static function ToUrlParams(array $values):string
    {
        $buff = "";
        foreach ($values as $k => $v)
        {
            if($k != "sign" && $v != "" && !is_array($v)){
                $buff .= $k . "=" . $v . "&";
            }
        }
        return trim($buff, "&");
    }

    /**
     * 生成签名
     * @param  array        $params  签名数组
     * @return string   签名
     * @throws
     */
    private static function MakeSign(array $params):string
    {
        $config = self::$config;
        //签名步骤一：按字典序排序参数
        ksort($params);
        $string = self::ToUrlParams($params);
        //签名步骤二：在string后加入KEY
        $string = $string . "&key=".$config['key'];
        //签名步骤三：MD5加密或者HMAC-SHA256
        if($config['sign_type'] == "MD5"){
            $string = md5($string);
        } else if($config['sign_type'] == "HMAC-SHA256") {
            $string = hash_hmac("sha256",$string ,$config['key']);
        } else {
            throw new \Exception("签名类型不支持！");
        }

        //签名步骤四：所有字符转为大写
        return strtoupper($string);
    }

}

